Skip to content

Add share-intent target API for watchapps#171

Open
killdano wants to merge 2 commits into
coredevices:masterfrom
killdano:share-intent-target-api
Open

Add share-intent target API for watchapps#171
killdano wants to merge 2 commits into
coredevices:masterfrom
killdano:share-intent-target-api

Conversation

@killdano
Copy link
Copy Markdown

@killdano killdano commented May 2, 2026

Lets watchapps register as Android share targets so users can "Share to " from any other app on their phone. The shared payload (text, URL, or both) routes to the watchapp's PKJS via a new 'shareintent' event listener.

This is a platform capability that benefits any watchapp accepting external input — navigation apps receiving Maps URLs, music apps receiving Spotify links, note-taking apps receiving selected text, etc. The motivating use case is MirrorMap (a Pebble nav watchapp), where sharing a Google Maps URL into the watchapp is the primary user flow, but nothing in this PR is Maps-specific.

What's new

Watchapp side

Watchapps opt in via two new fields in package.json:

{
  "shareTarget": {
    "mimeTypes": ["text/plain"]
  }
}

PKJS receives shared payloads via:

Pebble.addEventListener('shareintent', function(e) {
  // e.text — the shared text/URL
});

Android app side

Three Android share-sheet surfacing strategies, layered for compatibility:

  1. Static manifest entry ("Pebble") — universal fallback, works on every share-sheet implementation
  2. Sharing Shortcuts with Direct Share metadata — surfaces individual watchapps as named entries on Android 11+ where supported
  3. ChooserTargetService compat — same goal for older / Samsung-style share sheets where Sharing Shortcuts don't surface

When the static "Pebble" path is taken and multiple watchapps subscribe to the shared MIME type, the user is presented with a chooser dialog to pick the destination watchapp. When only one watchapp subscribes, dispatch is direct (no extra tap).

Per-watchapp icons in the share sheet

Each watchapp's share-target shortcut renders with the watchapp's own icon extracted from its installed PBW. This required a small .pbpack resource pack parser (PbwResourcePack.kt) since Pebble watchapps use a custom binary format that bundles all resources into a single file. The parser walks the manifest and resource table to extract the menu icon (declared with menuIcon: true in package.json's resources array) as a raw PNG, then falls back to scanning the PBW zip root for any PNG if the resource pack lookup fails.

The extracted icons are tinted via ColorMatrix to a brand-cohesive appearance for share-target presentation while preserving alpha for anti-aliasing on rounded backgrounds.

Java-side short URL resolver

Many sharing flows generate short URLs that redirect to the actual content URL (Google Maps' maps.app.goo.gl, Twitter's t.co, etc.). PKJS can in principle resolve these via XHR, but in practice many short URL services employ aggressive anti-bot measures that block XMLHttpRequest's User-Agent.

ShareUrlResolver runs the redirect resolution on the Android side using the existing OkHttp/Ktor stack with a standard browser-like User-Agent. The resolved long URL is what gets delivered to PKJS via the shareintent event, so watchapp authors don't have to re-implement HTTP redirect following in JavaScript or work around bot-detection headers.

The resolver:

  • Has structured concurrency (cancels cleanly with the share dispatch)
  • Retries once on transient network failure
  • Uses a dedicated HttpClient instance so its configuration doesn't leak into other libpebble3 HTTP usage
  • Times out at 10s — beyond that the share fails gracefully

Currently configured for Google Maps short URL hosts; extending to other short URL services is a one-line addition.

Cold-start handling

Share intents commonly arrive while the target watchapp is not currently running on the watch. The dispatch path:

  1. Receives the share intent via the new manifest entry / shortcut
  2. Looks up the subscribed watchapp(s) by MIME type
  3. Asks the libpebble3 connection to launch the watchapp on the watch
  4. Waits for PKJS to signal "ready" (CMD_INIT round-trip complete)
  5. Delivers the share event

The launch + ready-signal phase has a 30s timeout for cold-starts where the watchapp PBW needs to be transferred fresh, with a sub-timeout on the producer flow for the initial state read.

Testing notes

End-to-end validated on a Samsung Galaxy + Pebble Time Steel with MirrorMap as the share target. Tested:

  • Cold-start (watchapp not running, app not in foreground) — share intent triggers watchapp launch, share is delivered after ready signal
  • Warm-start (watchapp foreground) — share is delivered without relaunch
  • Multi-watchapp scenario — chooser dialog surfaces correctly
  • Per-watchapp icons render with their actual PBW menu icon
  • Short URL resolution — maps.app.goo.gl URLs resolve to the long Maps directions URL before PKJS sees them

Files

New:

  • composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt
  • composeApp/src/androidMain/res/xml/share_targets.xml
  • libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt
  • libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt

Modified:

  • composeApp/build.gradle.kts — Direct Share / shortcut metadata deps
  • composeApp/src/androidMain/AndroidManifest.xml — share-target manifest entry
  • composeApp/src/androidMain/kotlin/coredevices/coreapp/MainApplication.kt — bootstrap
  • composeApp/src/androidMain/kotlin/coredevices/coreapp/di/androidDefaultModule.kt — koin
  • gradle/libs.versions.toml — version catalog updates
  • libpebble3/src/androidMain/assets/startup.js — PKJS event registration
  • libpebble3/src/androidMain/kotlin/.../js/WebViewJsRunner.kt — event dispatch
  • libpebble3/src/commonMain/kotlin/.../di/LibPebbleModule.kt — koin
  • libpebble3/src/commonMain/kotlin/.../disk/pbw/DiskUtil.kt — resource pack helpers
  • libpebble3/src/commonMain/kotlin/.../js/JsRunner.kt — event interface
  • libpebble3/src/commonMain/kotlin/.../js/PKJSApp.kt — ready signal observation
  • libpebble3/src/commonMain/kotlin/.../metadata/pbw/appinfo/PbwAppInfo.kt — shareTarget field
  • libpebble3/src/iosMain/kotlin/.../js/JavascriptCoreJsRunner.kt — iOS no-op stub

Lets watchapps register as Android share targets so users can "Share to
<watchapp>" from any other app on their phone. The shared payload (text,
URL, or both) routes to the watchapp's PKJS via a new `'shareintent'`
event listener.

This is a platform capability that benefits any watchapp accepting
external input — navigation apps receiving Maps URLs, music apps
receiving Spotify links, note-taking apps receiving selected text, etc.
The motivating use case is MirrorMap (a Pebble nav watchapp), where
sharing a Google Maps URL into the watchapp is the primary user flow,
but nothing in this PR is Maps-specific.

## What's new

### Watchapp side

Watchapps opt in via two new fields in `package.json`:

```json
{
  "shareTarget": {
    "mimeTypes": ["text/plain"]
  }
}
```

PKJS receives shared payloads via:

```javascript
Pebble.addEventListener('shareintent', function(e) {
  // e.text — the shared text/URL
});
```

### Android app side

Three Android share-sheet surfacing strategies, layered for compatibility:

1. **Static manifest entry** ("Pebble") — universal fallback, works on every
   share-sheet implementation
2. **Sharing Shortcuts** with Direct Share metadata — surfaces individual
   watchapps as named entries on Android 11+ where supported
3. **ChooserTargetService** compat — same goal for older / Samsung-style
   share sheets where Sharing Shortcuts don't surface

When the static "Pebble" path is taken and multiple watchapps subscribe to
the shared MIME type, the user is presented with a chooser dialog to pick
the destination watchapp. When only one watchapp subscribes, dispatch is
direct (no extra tap).

### Per-watchapp icons in the share sheet

Each watchapp's share-target shortcut renders with the watchapp's own icon
extracted from its installed PBW. This required a small `.pbpack` resource
pack parser (`PbwResourcePack.kt`) since Pebble watchapps use a custom
binary format that bundles all resources into a single file. The parser
walks the manifest and resource table to extract the menu icon (declared
with `menuIcon: true` in `package.json`'s resources array) as a raw PNG,
then falls back to scanning the PBW zip root for any PNG if the resource
pack lookup fails.

The extracted icons are tinted via ColorMatrix to a brand-cohesive
appearance for share-target presentation while preserving alpha for
anti-aliasing on rounded backgrounds.

### Java-side short URL resolver

Many sharing flows generate short URLs that redirect to the actual content
URL (Google Maps' `maps.app.goo.gl`, Twitter's `t.co`, etc.). PKJS can in
principle resolve these via XHR, but in practice many short URL services
employ aggressive anti-bot measures that block XMLHttpRequest's User-Agent.

`ShareUrlResolver` runs the redirect resolution on the Android side using
the existing OkHttp/Ktor stack with a standard browser-like User-Agent.
The resolved long URL is what gets delivered to PKJS via the `shareintent`
event, so watchapp authors don't have to re-implement HTTP redirect
following in JavaScript or work around bot-detection headers.

The resolver:
- Has structured concurrency (cancels cleanly with the share dispatch)
- Retries once on transient network failure
- Uses a dedicated HttpClient instance so its configuration doesn't leak
  into other libpebble3 HTTP usage
- Times out at 10s — beyond that the share fails gracefully

Currently configured for Google Maps short URL hosts; extending to other
short URL services is a one-line addition.

## Cold-start handling

Share intents commonly arrive while the target watchapp is not currently
running on the watch. The dispatch path:

1. Receives the share intent via the new manifest entry / shortcut
2. Looks up the subscribed watchapp(s) by MIME type
3. Asks the libpebble3 connection to launch the watchapp on the watch
4. Waits for PKJS to signal "ready" (CMD_INIT round-trip complete)
5. Delivers the share event

The launch + ready-signal phase has a 30s timeout for cold-starts where
the watchapp PBW needs to be transferred fresh, with a sub-timeout on the
producer flow for the initial state read.

## Testing notes

End-to-end validated on a Samsung Galaxy + Pebble Time Steel with MirrorMap
as the share target. Tested:

- Cold-start (watchapp not running, app not in foreground) — share intent
  triggers watchapp launch, share is delivered after ready signal
- Warm-start (watchapp foreground) — share is delivered without relaunch
- Multi-watchapp scenario — chooser dialog surfaces correctly
- Per-watchapp icons render with their actual PBW menu icon
- Short URL resolution — `maps.app.goo.gl` URLs resolve to the long Maps
  directions URL before PKJS sees them

## Files

New:
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/sharing/ShareTargetActivity.kt`
- `composeApp/src/androidMain/res/xml/share_targets.xml`
- `libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetSync.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/ShareIntentModule.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/disk/pbw/PbwResourcePack.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareIntentDispatcher.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetEntry.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareTargetsProducer.kt`
- `libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt`

Modified:
- `composeApp/build.gradle.kts` — Direct Share / shortcut metadata deps
- `composeApp/src/androidMain/AndroidManifest.xml` — share-target manifest entry
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/MainApplication.kt` — bootstrap
- `composeApp/src/androidMain/kotlin/coredevices/coreapp/di/androidDefaultModule.kt` — koin
- `gradle/libs.versions.toml` — version catalog updates
- `libpebble3/src/androidMain/assets/startup.js` — PKJS event registration
- `libpebble3/src/androidMain/kotlin/.../js/WebViewJsRunner.kt` — event dispatch
- `libpebble3/src/commonMain/kotlin/.../di/LibPebbleModule.kt` — koin
- `libpebble3/src/commonMain/kotlin/.../disk/pbw/DiskUtil.kt` — resource pack helpers
- `libpebble3/src/commonMain/kotlin/.../js/JsRunner.kt` — event interface
- `libpebble3/src/commonMain/kotlin/.../js/PKJSApp.kt` — ready signal observation
- `libpebble3/src/commonMain/kotlin/.../metadata/pbw/appinfo/PbwAppInfo.kt` — shareTarget field
- `libpebble3/src/iosMain/kotlin/.../js/JavascriptCoreJsRunner.kt` — iOS no-op stub
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 2, 2026

CLA assistant check
All committers have signed the CLA.

Replaces the 2-attempt 300ms-fixed-delay retry in ShareUrlResolver
with a 4-attempt exponential schedule (0, 2s, 4s, 6s) with +/-200ms
symmetric jitter.

Motivation: field testing showed Google's short-URL service
returning 404 stochastically -- both attempts in the previous
2-attempt loop hit 404 within ~150ms of each other, falling open
to PKJS's XHR fallback which then hits 403 (Google's anti-bot
hits XHR harder than OkHttp).

Spreading attempts across ~13s with jitter avoids the regular-
interval polling pattern that contributes to the bot heuristic,
while still bounded by a 16s overall timeout. Happy path stays
snappy (attempt 1 immediate +/-200ms jitter).
@killdano killdano force-pushed the share-intent-target-api branch from ff3b04b to f82d303 Compare May 3, 2026 03:46
@sjp4
Copy link
Copy Markdown
Member

sjp4 commented May 5, 2026

I'm not convinced this belongs in the Pebble app vs in an Android companion app. PKJS is supposed to be a cross-platform framework but this is completely android-only. There's also a lot of code in here which looks very specific to the Maps use-case. I didn't fully review but it looks like there are a lot of hacks (increasing PKJS complexity in a way I don't like the look of), and generally an abundance of code (and comments).

@ericmigi
Copy link
Copy Markdown
Contributor

ericmigi commented May 5, 2026

might be my fault - I thought this might be a more generalizable path (eg sharing an image with a watchface from your photo album)

@killdano
Copy link
Copy Markdown
Author

killdano commented May 5, 2026

Thanks for that info, @sjp4 -- I appreciate you taking the time to analyze all this especially coming from a complete stranger.

@ericmigi — your photo-album-to-watchface example is what I was thinking too-- Things like this:

  • Strava routes/activities shared from Strava app to a 3rd party pebble fitness app.
  • Note-taking watchapps receiving selected text or URLs
  • Sending written addresses, gmaps links to Nav apps.
  • Hiking Pebble map apps receiving GPX files from any phone app that makes them.
    That's just today-- Think of how useful it will become in the future!

Each of those is one watchapp + one PKJS event handler away from working, with no platform changes needed beyond what's in this PR. The MIME-type filter in package.json means watchapps can declare what they accept (text, image, etc.), and Android share-sheet routing handles the rest.

Sharing to pebble apps would really put an end to many companion app needs, making the user experience less fragmented.

@sjp4 — your concerns are fair, especially on the Maps-specificity.

ShareUrlResolver is genuinely Maps-specific and shouldn't be in libpebble3. The motivation was that PKJS XHR can't override User-Agent, and Google's maps.app.goo.gl URLs return 403 to the default UA — but that's my problem to solve, not the platform's. If we continue on this, I'll move URL resolution to a small backend endpoint of mine and drop ShareUrlResolver from the PR entirely.

The comments and code volume — fair. Some of the bulk is necessary plumbing (Direct Share + ChooserTargetService compat is genuinely two paths because Android changed the API; PbwResourcePack exists because watchapp icons live in a custom binary format). But comment density is on me — I tend to over-document while iterating, and that needs trimming before review.

The cross-platform concern — iOS does support an equivalent capability via Share Extensions (UIActivityViewController). The PKJS API I'm adding (the shareTarget package.json field, the shareintent event) is platform-neutral; only the implementation in this PR is Android. I'd be glad to scope a follow-up PR for the iOS Share Extension to keep parity. Same shape as PR #172's notificationFilter — unified PKJS API, OS-mechanic-specific implementations underneath. (MirrorMap aims to be cross-platform post-launch, so I'm motivated to do that work either way.)

If you are open to giving me another shot, I can do either:

A: Push a revision to this PR (drop ShareUrlResolver, prune comments, tighten the diff), or

B: Close this PR and open a smaller, cleaner one with just the generic primitive (share intent → PKJS event), with per-watchapp icon extraction and the iOS implementation as separate follow-ups.

I'm fine with either — whichever is easier for you to review.

@killdano
Copy link
Copy Markdown
Author

killdano commented May 8, 2026

Just learned there are high res versions of app icons already available for the watch apps that users have installed. @sjp4
If we end up moving forward with updates on this, I'll be sure to use the crisp icons that are already available instead of the 25x25 PNGs in the PBW.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants